Skip to content

C++ 异常处理 - 第19次课

第一部分: 引言 - 为什么需要异常处理?

  • 1.1 程序中的错误和异常

    • 任何程序在运行时都可能遇到错误或意外情况,我们称之为异常 (Exception)
    • 这些异常可能是由程序逻辑错误引起的(如除零错误),也可能是由外部因素导致的(如文件不存在、内存不足)。
    • 区别于编译时错误,异常发生在程序运行时
  • 1.2 传统的错误处理方式及其局限性

    • 返回值判断: 函数通过返回特定的值来表示错误状态。
      • 优点: 简单直观。
      • 缺点:
        • 代码冗余:需要在每个可能出错的地方检查返回值。
        • 容易忽略错误:程序员可能忘记检查返回值。
        • 返回值歧义:返回值本身可能就是合法的结果,难以区分错误和正常结果。
        • 函数调用链深时,错误信息难以向上层传递。
  • 1.3 异常处理的优势

    • 分离错误处理代码和正常逻辑代码: 使代码结构更清晰,可读性更高。
    • 更灵活的错误处理机制: 可以跨越多个函数调用栈来传递和处理错误。
    • 提高程序健壮性: 防止程序因未处理的错误而崩溃。
    • 强制处理异常: 必须在某个地方捕获并处理抛出的异常,避免忽略错误。
    • 提供更丰富的错误信息: 异常对象可以携带关于错误的详细信息。

代码示例 (传统的错误处理方式的局限性):

c++
#include <iostream>
#include <fstream>

int readFile(const std::string& filename) {
  std::ifstream file(filename);
  if (!file.is_open()) {
    return -1; // 文件打开失败
  }
  // ... 读取文件内容 ...
  return 0; // 成功
}

void processFile(const std::string& filename) {
  if (readFile(filename) == -1) {
    std::cerr << "Error: Failed to open file " << filename << std::endl;
    // 这里可能还需要进行其他错误处理,例如通知用户
  } else {
    // ... 处理文件内容 ...
    std::cout << "File processed successfully." << std::endl;
  }
}

int main() {
  processFile("non_existent_file.txt");
  return 0;
}

思考:processFile 函数中,如果 readFile 返回 -1,我们只简单地输出了错误信息。如果 readFile 在更深的调用栈中,错误信息可能丢失或处理不当。

第二部分: C++ 异常处理机制详解

  • 2.1 try, catch, throw 关键字

    • try 块: 用于包含可能抛出异常的代码。程序会尝试执行 try 块中的代码。
    • catch 块: 用于捕获并处理特定类型的异常。一个 try 块可以跟随多个 catch 块,每个 catch 块处理不同类型的异常。
    • throw 关键字: 用于抛出一个异常对象,表示发生了某种错误。
  • 2.2 异常类型

    • 内置异常类型: C++ 标准库提供了一组预定义的异常类,它们都继承自 std::exception 类。
      • std::runtime_error: 运行时错误,例如逻辑错误、超出范围等。
      • std::logic_error: 程序逻辑错误,例如违反前提条件。
      • std::bad_alloc: 内存分配失败。
      • std::out_of_range: 超出有效范围。
      • std::domain_error: 数学函数定义域错误。
      • ... 等等。
    • 自定义异常类型: 可以通过继承 std::exception 类或其子类来创建自己的异常类型,以便更精确地表达程序中的错误。
  • 2.3 异常处理流程 (栈展开 - Stack Unwinding)

    1. 当在 try 块中的代码抛出一个异常时,程序会立即停止执行 try 块中剩余的代码。
    2. 系统会沿着函数调用栈向上查找,寻找与抛出的异常类型匹配catch 块。
    3. 匹配规则:
      • catch 块参数的类型必须与抛出的异常类型相同,或者是抛出异常类型的基类。
      • catch(...) 可以捕获任何类型的异常。
    4. 如果找到匹配的 catch 块,则执行该 catch 块中的代码,异常被处理
    5. 如果沿着调用栈一直找不到匹配的 catch 块,程序会调用 std::terminate() 函数并异常终止。这个过程称为栈展开 (Stack Unwinding),在栈展开的过程中,局部对象的析构函数会被调用,以确保资源得到释放。

代码示例 (使用异常处理机制):

c++
#include <iostream>
#include <stdexcept>

int divide(int a, int b) {
  if (b == 0) {
    throw std::runtime_error("Error: Division by zero!"); // 抛出异常对象
  }
  return a / b;
}

int main() {
  try {
    int result = divide(10, 0); // 可能抛出异常
    std::cout << "Result: " << result << std::endl; // 如果没有抛出异常,则执行这里
  } catch (const std::runtime_error& e) { // 捕获 std::runtime_error 类型的异常
    std::cerr << "Caught an exception: " << e.what() << std::endl; // 使用 what() 方法获取异常信息
  } catch (...) { // 捕获其他所有类型的异常 (不推荐过度使用)
    std::cerr << "Caught an unknown exception!" << std::endl;
  }
  std::cout << "Program continues after exception handling." << std::endl;
  return 0;
}

思考:divide 函数抛出异常时,try 块中的剩余代码被跳过,程序查找匹配的 catch 块。如果注释掉 catch (const std::runtime_error& e),程序会发生什么?

第三部分: 自定义异常类型

  • 3.1 继承 std::exception

    • 自定义异常类型通常通过公有继承 std::exception 类或其子类来实现。
    • 这样做的好处是可以使用标准的异常处理框架,并且可以重写 what() 方法来提供自定义的错误信息。
  • 3.2 重写 what() 函数

    • std::exception 类提供了一个虚函数 what(),用于返回一个描述异常的 C 风格字符串。
    • 在自定义异常类中,通常需要重写 what() 函数,以便返回更具体、更有意义的错误信息。

代码示例 (自定义异常类型):

c++
#include <iostream>
#include <exception>
#include <string>

class FileOpenError : public std::runtime_error {
public:
  FileOpenError(const std::string& filename) : std::runtime_error("Failed to open file: " + filename), filename_(filename) {}

  const std::string& getFilename() const {
    return filename_;
  }

private:
  std::string filename_;
};

int main() {
  try {
    std::string filename = "important_data.txt";
    // 模拟文件打开失败的情况
    if (true) {
      throw FileOpenError(filename);
    }
    // ... 其他文件操作 ...
  } catch (const FileOpenError& e) {
    std::cerr << "File error: " << e.what() << std::endl;
    std::cerr << "Filename: " << e.getFilename() << std::endl;
  } catch (const std::runtime_error& e) {
    std::cerr << "Runtime error: " << e.what() << std::endl;
  }
  return 0;
}

思考: 自定义异常类型的好处是什么?在上面的代码中,我们如何获取导致异常的文件名?

第四部分: 异常处理的最佳实践

  • 4.1 仅在必要时使用异常

    • 异常应该用于处理真正异常的情况,即程序无法正常继续执行的错误。
    • 对于可以预见的错误,例如用户输入错误,可以考虑使用其他方式(如返回值、错误码)处理。
  • 4.2 抛出和捕获特定类型的异常

    • 尽量抛出和捕获具体类型的异常,而不是仅仅使用 catch (...)
    • 这样做可以提供更精确的错误处理,避免捕获不应该捕获的异常。
  • 4.3 不要在析构函数中抛出异常 非常重要

    • 在栈展开的过程中,可能会有多个析构函数被调用。如果在析构函数中抛出异常且未被捕获,程序会调用 std::terminate() 终止。这可能会导致资源泄漏或其他问题。
    • 如果析构函数中可能发生错误,应该在析构函数内部处理,例如记录错误日志。
  • 4.4 使用 noexcept 声明不抛出异常的函数

    • 使用 noexcept 关键字声明函数不会抛出异常。
    • 这可以帮助编译器进行优化,并且在某些情况下可以提高代码的可靠性。
    • 如果 noexcept 函数内部确实抛出了异常且未被捕获,程序会调用 std::terminate()
  • 4.5 在适当的层次捕获异常

    • 在能够合理处理异常的地方捕获异常。
    • 不要过早地捕获异常,导致上层调用者无法获得足够的错误信息。
    • 也不要忽略异常,导致程序在未知的状态下继续运行。
  • 4.6 提供清晰的异常信息

    • 异常对象应该携带足够的错误信息,方便定位和解决问题。
    • 在自定义异常类中,what() 方法应该返回清晰易懂的错误描述。
  • 4.7 考虑资源管理(RAII)

    • 结合 RAII (Resource Acquisition Is Initialization) 原则,可以使用智能指针等技术来自动管理资源,即使在发生异常的情况下也能确保资源被正确释放。

第五部分: 总结

  • C++ 的异常处理机制是一种强大的错误处理工具,可以提高程序的健壮性和可维护性。
  • 掌握 trycatchthrow 关键字以及异常处理流程是使用异常处理的基础。
  • 理解内置异常类型和自定义异常类型,并学会根据需要创建自定义异常。
  • 遵循异常处理的最佳实践,编写更可靠、更易于维护的代码。

课后作业:

编写一个程序,模拟银行账户操作,包括存款、取款等功能。使用异常处理机制来处理以下情况:

  • 取款金额超过余额.
  • 存款金额为负数.

作业提示:

  • 定义一个 BankAccount 类,包含 balance 成员变量.
  • 定义 depositwithdraw 成员函数,分别用于存款和取款.
  • withdraw 函数中,如果取款金额超过余额,则抛出一个自定义异常 InsufficientFundsException.
  • deposit 函数中,如果存款金额为负数,则抛出一个自定义异常 InvalidDepositAmountException.
  • main 函数中,使用 try-catch 块来处理这些异常.